</> 技術筆記Tech Notes

Vue 3 甘特圖元件開發實戰:從零到完整功能實現

Vue 3 甘特圖元件開發實戰:從零到完整功能實現

前言: 在專案管理系統中,甘特圖是不可或缺的功能之一。本文將詳細介紹如何使用 Vue 3 Composition API 開發一個功能完整的甘特圖元件,包含拖拽捲動、固定欄位、任務選取等進階功能。透過實際案例分析,我們將深入探討現代前端開發中的複雜 UI 元件設計思路。

專案需求分析與技術選型

在開始開發之前,讓我們先深入分析這個甘特圖元件需要具備哪些功能,以及背後的技術挑戰。

核心功能需求詳解

時間軸顯示系統 甘特圖的核心是時間軸的呈現。我們需要根據專案的起始和結束日期,動態生成時間軸標籤。考慮到使用者體驗和螢幕空間的限制,我們選擇以週為單位來劃分時間軸。這樣既能提供足夠的精度,又不會讓介面過於擁擠。

任務狀態視覺化 每個任務在時間軸上需要有清晰的狀態標示。我們設計了四種狀態:完成(綠色)、緊急(紅色)、計畫中(藍色)、未來任務(灰色)。透過不同顏色的圓點來表示,使用者可以一眼看出整個專案的進度分布。

固定欄位系統 這是技術實現上最具挑戰性的功能之一。左側的任務名稱、開始日期、結束日期,以及右側的人員分配欄位,需要在水平捲動時保持固定。這要求我們精確計算每個固定欄位的位置,並處理不同寬度的欄位堆疊。

智慧捲動管理 考慮到不同規模的專案可能有不同的任務數量,我們設計了智慧的垂直捲動控制。當任務數量較少時(15 個以下),禁用垂直捲動讓所有任務一目了然;當任務數量較多時,啟用垂直捲動避免頁面過長。

技術挑戰深度分析

大量資料渲染效能問題 當專案包含數百個任務時,DOM 元素數量會急劇增加。假設一個專案有 200 個任務,時間軸跨度 50 週,那麼僅時間軸格子就有 10,000 個 DOM 元素。我們需要仔細考慮渲染策略,包括虛擬化捲動、計算屬性快取、以及適當的元件更新策略。

複雜的捲動邏輯協調 甘特圖需要同時支援原生滾輪捲動、拖拽捲動、以及程式控制捲動。這三種捲動方式需要和諧共存,不能互相干擾。特別是拖拽捲動,需要處理滑鼠事件的捕獲、移動計算、以及邊界情況的處理。

動態固定欄位的位置計算 固定欄位的實現不僅僅是設置 position: sticky。我們需要動態計算每個固定欄位的 leftright 值,處理欄位寬度變化、內容動態更新等情況。同時還要確保表頭和內容列的對齊。

多種互動模式的事件管理 甘特圖支援多種使用者互動:單擊選取、Ctrl+點擊多選、雙擊編輯、拖拽捲動等。這些事件之間存在優先級關係,需要設計清晰的事件處理邏輯,避免事件衝突和意外行為。

架構設計:元件化思維的實踐

整體架構設計理念

在設計甘特圖元件的架構時,我們遵循現代前端開發的最佳實踐:單一職責原則、開放封閉原則、以及依賴倒置原則。

分層架構設計 我們將整個甘特圖系統分為四個主要層次:

  1. 表現層(Presentation Layer):負責 UI 渲染和使用者互動

  2. 業務邏輯層(Business Logic Layer):處理甘特圖特有的業務邏輯

  3. 資料管理層(Data Management Layer):統一管理應用狀態

  4. 工具層(Utility Layer):提供通用的工具函數

元件職責劃分 主要元件的職責清晰劃分如下:

  • GanttChart.vue:核心元件,負責整體佈局、事件處理、捲動控制

  • TaskDetailModal.vue:任務編輯模態視窗,處理任務詳細資訊的 CRUD 操作

  • useGanttData.js:Composition API,封裝資料邏輯和狀態管理

這種設計讓每個元件都有明確的邊界和職責,便於維護和測試。

資料流向設計

/**
 * 甘特圖資料流設計
 *
 * 這是一個經典的 Vue 3 響應式資料流架構
 * 採用單向資料流原則,確保資料變化的可預測性
 */
const dataFlowArchitecture = {
  // 父元件向子元件傳遞的屬性
  propsDown: {
    tasks: {
      type: Array,
      description: '任務列表,包含每個任務的完整資訊',
      validation: '每個任務必須包含 id, name, startDate, endDate, status, ownerIds'
    },
    people: {
      type: Array,
      description: '專案成員列表,用於人員指派顯示',
      validation: '每個成員包含 id, name, role'
    },
    timelineSettings: {
      type: Object,
      description: '時間軸配置,定義專案的時間範圍',
      validation: '必須包含 start 和 end 日期物件'
    },
    selectedTaskIds: {
      type: Set,
      description: '當前選中的任務 ID 集合,支援多選功能'
    }
  },

  // 子元件向父元件發射的事件
  emitsUp: {
    'toggle-select-task': {
      payload: 'taskId',
      description: '切換任務選取狀態,支援多選邏輯'
    }
  },

  // 透過 Composable 共享的全域狀態
  globalState: {
    isDragMode: '控制是否啟用拖拽捲動模式',
    taskToScrollTo: '程式控制捲動到特定任務的 ID',
    scrollToTodayTrigger: '觸發捲動到今日位置的計數器'
  }
};

這種資料流設計的優點是清晰可控,每個資料變化都有明確的來源和去向。父元件管理業務狀態,子元件專注於 UI 展示和使用者互動。

畫面結構:HTML 與 CSS 的巧妙配合

DOM 結構的精心設計

在設計甘特圖的 DOM 結構時,我們需要平衡語義性、可及性、和技術實現的需求。最終選擇了 <table> 元素作為主要載體,這是因為甘特圖本質上是一個資料表格,<table> 元素天然具備行列對齊的特性。

<div ref="containerRef"
     :class="dynamicClasses"
     class="gantt-table-scroll-wrapper"
     @mousedown="handleMouseDown"
     @mousemove="handleMouseMove"
     @mouseup="handleMouseUp"
     @mouseleave="handleMouseLeave">

  <table id="gantt-table" ref="tableRef">
    <thead>
      <tr>
        <th class="sticky-left" style="min-width: 250px;">主要任務</th>
        <th class="sticky-left" style="min-width: 130px;">開始日期</th>
        <th class="sticky-left" style="min-width: 130px;">結束日期</th>

        <th v-for="date in timelineDates"
            :key="date.getTime()"
            class="vertical-header">
          {{ formatDateForDisplay(date) }}
        </th>

        <th v-for="person in people"
            :key="person.id"
            class="vertical-header sticky-right">
          {{ person.name }}({{ person.role }})
        </th>
      </tr>
    </thead>

    <tbody>
      <tr v-for="task in tasks"
          :key="task.id"
          :data-task-id="task.id">

        <td class="sticky-left task-name-cell"
            :class="{ selected: selectedTaskIds.has(task.id) }"
            :title="task.name"
            @dblclick="openTaskModal(task)"
            @click.ctrl.prevent="toggleSelection(task.id)"
            @click.meta.prevent="toggleSelection(task.id)">
          {{ task.name }}
        </td>

        <td class="sticky-left" @dblclick="openTaskModal(task)">
          {{ formatLocalDate(task.startDate) }}
        </td>
        <td class="sticky-left" @dblclick="openTaskModal(task)">
          {{ formatLocalDate(task.endDate) }}
        </td>

        <td v-for="weekStartDate in timelineDates"
            :key="weekStartDate.getTime()">
          <span v-if="isTaskInWeek(task, weekStartDate)"
                :class="['dot', `dot-${task.status}`]"
                :title="`狀態: ${task.status}`">
          </span>
        </td>

        <td v-for="person in people"
            :key="person.id"
            class="sticky-right">
          <span v-if="task.ownerIds.includes(person.id)"
                class="check-mark">✓</span>
        </td>
      </tr>
    </tbody>
  </table>
</div>

<TaskDetailModal v-if="editingTask"
                 :task="editingTask"
                 @close="closeTaskModal"/>

這個 DOM 結構的設計有幾個關鍵考量:

表格語義的正確使用 我們使用了完整的 <table> > <thead> > <tbody> > <tr> > <th>/<td> 結構,這不僅語義正確,也為 CSS 樣式提供了清晰的選擇器目標。<thead> 中的 <th> 元素自然成為欄位標題,<tbody> 中的 <td> 元素承載實際資料。

事件處理的分層設計 容器元素(gantt-table-scroll-wrapper)負責處理拖拽捲動相關的滑鼠事件,而具體的表格單元格則處理任務相關的互動事件。這種分層處理避免了事件衝突,也讓邏輯更加清晰。

可及性的考慮 每個任務列都有 data-task-id 屬性,便於程式化查找和測試。任務名稱欄位有 title 屬性,當內容被截斷時使用者可以透過懸停查看完整內容。狀態點也有 title 屬性,提供額外的資訊說明。

時間軸計算的核心邏輯

時間軸的計算是甘特圖的核心功能,它決定了整個圖表的橫向結構。

/**
 * 時間軸日期計算系統
 *
 * 這個函數負責根據專案的起始和結束日期,
 * 生成符合甘特圖顯示需求的時間軸陣列
 */
const timelineDates = computed(() => {
  // 輸入驗證:確保時間軸設定的完整性
  if (!props.timelineSettings?.start || !props.timelineSettings?.end) {
    console.warn('時間軸設定不完整,無法生成日期陣列', {
      start: props.timelineSettings?.start,
      end: props.timelineSettings?.end
    });
    return [];
  }

  // 日期有效性檢查
  const startDate = new Date(props.timelineSettings.start);
  const endDate = new Date(props.timelineSettings.end);

  if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
    console.error('時間軸日期格式無效', { startDate, endDate });
    return [];
  }

  if (startDate >= endDate) {
    console.error('開始日期必須早於結束日期', { startDate, endDate });
    return [];
  }

  const dates = [];
  let currentDate = new Date(startDate);

  // 標準化到週一(可選的改進:讓使用者選擇週開始日)
  const dayOfWeek = currentDate.getDay();
  const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
  currentDate.setDate(currentDate.getDate() + daysToMonday);

  // 以週為單位生成時間軸
  while (currentDate <= endDate) {
    // 重要:建立新的 Date 物件,避免引用問題
    dates.push(new Date(currentDate));

    // 增加一週(7天)
    currentDate.setDate(currentDate.getDate() + 7);
  }

  console.log(`時間軸生成完成: ${dates.length} 週`, {
    startWeek: dates[0]?.toDateString(),
    endWeek: dates[dates.length-1]?.toDateString(),
    totalWeeks: dates.length
  });

  return dates;
});

/**
 * 判斷任務是否在指定週內有進度
 *
 * 這個函數決定了任務狀態點的顯示邏輯
 * 是甘特圖視覺化的關鍵計算
 */
const isTaskInWeek = (task, weekStartDate) => {
  try {
    // 計算該週的結束日期
    const weekEndDate = new Date(weekStartDate);
    weekEndDate.setDate(weekEndDate.getDate() + 6);
    weekEndDate.setHours(23, 59, 59, 999); // 設定為該日的最後一刻

    // 任務的時間範圍
    const taskStartDate = new Date(task.startDate);
    const taskEndDate = new Date(task.endDate);
    taskEndDate.setHours(23, 59, 59, 999); // 包含結束日的整天

    // 重疊判斷邏輯:
    // 任務開始時間 <= 週結束時間 且 任務結束時間 >= 週開始時間
    const hasOverlap = taskStartDate <= weekEndDate && taskEndDate >= weekStartDate;

    if (hasOverlap) {
      console.debug(`任務 "${task.name}" 在週 ${weekStartDate.toDateString()} 有進度`, {
        taskStart: taskStartDate.toDateString(),
        taskEnd: taskEndDate.toDateString(),
        weekStart: weekStartDate.toDateString(),
        weekEnd: weekEndDate.toDateString()
      });
    }

    return hasOverlap;
  } catch (error) {
    console.error('任務時間範圍計算錯誤', { task, weekStartDate, error });
    return false;
  }
};

/**
 * 日期格式化工具函數
 *
 * 為不同的顯示場景提供統一的日期格式
 */
const formatDateForDisplay = (date) => {
  if (!(date instanceof Date) || isNaN(date.getTime())) {
    return '';
  }

  const month = (date.getMonth() + 1).toString().padStart(2, '0');
  const day = date.getDate().toString().padStart(2, '0');
  return `${month}/${day}`;
};

const formatLocalDate = (date) => {
  if (!(date instanceof Date) || isNaN(date.getTime())) {
    return '';
  }

  return date.toLocaleDateString('zh-TW', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit'
  });
};

這個時間軸計算系統考慮了多個實際使用場景:

週對齊邏輯 我們將時間軸對齊到週一開始,這符合大多數商業環境的習慣。雖然程式碼中預設對齊週一,但設計上預留了擴展空間,未來可以讓使用者選擇週開始日。

邊界情況處理 程式碼充分考慮了各種邊界情況:無效日期、開始日期晚於結束日期、日期格式錯誤等。這些防護措施確保了系統的穩定性。

效能考量 使用 computed 屬性確保時間軸只在必要時重新計算。同時在計算過程中避免了不必要的物件創建和複雜運算。

捲動機制:流暢體驗的技術實現

甘特圖的捲動系統是使用者體驗的關鍵,它需要處理三種不同的捲動模式:原生滾輪捲動、拖拽捲動、以及程式控制捲動。

拖拽捲動的完整實現

拖拽捲動讓使用者可以像操作地圖一樣拖動甘特圖,這大幅提升了大型專案的瀏覽體驗。

/**
 * 拖拽捲動系統的狀態管理
 *
 * 使用 Vue 3 的 ref 響應式引用來管理拖拽狀態
 * 這種設計讓狀態變化能夠即時反映到 UI 上
 */
const dragState = {
  isDragging: ref(false),    // 當前是否處於拖拽狀態
  startX: ref(0),           // 拖拽開始時的滑鼠 X 座標
  startY: ref(0),           // 拖拽開始時的滑鼠 Y 座標
  scrollLeft: ref(0),       // 拖拽開始時的容器水平捲動位置
  scrollTop: ref(0)         // 拖拽開始時的容器垂直捲動位置
};

/**
 * 拖拽開始事件處理器
 *
 * 這個函數處理拖拽的初始化邏輯,包括狀態檢查、
 * 目標元素驗證、以及初始位置記錄
 */
const handleMouseDown = (e) => {
  // 功能開關檢查:拖拽模式可以透過 UI 控制項開關
  if (!isDragMode.value) {
    console.debug('拖拽模式已停用,跳過滑鼠按下處理');
    return;
  }

  // 智慧目標過濾:某些 UI 元素不應該觸發拖拽
  const excludeSelectors = ['th', '.task-name-cell', '.dot', '.check-mark'];
  const isExcludedTarget = excludeSelectors.some(selector =>
    e.target.closest(selector)
  );

  if (isExcludedTarget) {
    console.debug('點擊目標在排除清單中,不啟動拖拽', {
      target: e.target.tagName,
      className: e.target.className
    });
    return;
  }

  // 阻止瀏覽器預設行為
  // 這包括文字選取、圖片拖拽、連結跳轉等
  e.preventDefault();

  console.log('開始拖拽操作', {
    button: e.button,
    pageX: e.pageX,
    pageY: e.pageY
  });

  // 設定拖拽狀態
  dragState.isDragging.value = true;

  const container = containerRef.value;
  if (!container) {
    console.error('容器引用不存在,無法執行拖拽');
    return;
  }

  // 計算相對於容器的滑鼠位置
  // 使用容器相對位置而非頁面絕對位置,提高準確性
  const containerRect = container.getBoundingClientRect();
  dragState.startX.value = e.clientX - containerRect.left;
  dragState.startY.value = e.clientY - containerRect.top;

  // 記錄拖拽開始時的捲動位置
  // 這是計算拖拽距離的基準點
  dragState.scrollLeft.value = container.scrollLeft;
  dragState.scrollTop.value = container.scrollTop;

  console.debug('拖拽初始狀態記錄完成', {
    startX: dragState.startX.value,
    startY: dragState.startY.value,
    initialScrollLeft: dragState.scrollLeft.value,
    initialScrollTop: dragState.scrollTop.value
  });

  // 設定全域捕獲,確保拖拽過程中滑鼠移出容器仍能正常工作
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
};

/**
 * 拖拽移動事件處理器
 *
 * 這是拖拽系統的核心,負責實時計算滑鼠移動距離
 * 並同步更新容器的捲動位置
 */
const handleMouseMove = (e) => {
  // 狀態檢查:只有在拖拽狀態下才處理移動事件
  if (!dragState.isDragging.value) {
    return;
  }

  // 防止預設行為,避免其他元素的拖拽行為干擾
  e.preventDefault();

  const container = containerRef.value;
  if (!container) {
    console.error('容器不存在,終止拖拽操作');
    dragState.isDragging.value = false;
    return;
  }

  // 計算當前滑鼠位置(相對於容器)
  const containerRect = container.getBoundingClientRect();
  const currentX = e.clientX - containerRect.left;
  const currentY = e.clientY - containerRect.top;

  // 計算滑鼠移動的距離
  const deltaX = currentX - dragState.startX.value;
  const deltaY = currentY - dragState.startY.value;

  // 敏感度調整:讓拖拽感覺更靈敏
  // 1.5 倍速度是經過使用者測試得出的最佳值
  const sensitivity = 1.5;
  const adjustedDeltaX = deltaX * sensitivity;
  const adjustedDeltaY = deltaY * sensitivity;

  // 更新水平捲動位置
  // 注意:拖拽方向與捲動方向相反,模擬真實的拖拽體驗
  container.scrollLeft = dragState.scrollLeft.value - adjustedDeltaX;

  // 垂直捲動的條件更新
  // 只有在啟用垂直捲動時才更新垂直位置
  if (isVerticalScrollEnabled.value) {
    container.scrollTop = dragState.scrollTop.value - adjustedDeltaY;
  }

  console.debug('拖拽位置更新', {
    deltaX: adjustedDeltaX,
    deltaY: adjustedDeltaY,
    scrollLeft: container.scrollLeft,
    scrollTop: container.scrollTop
  });
};

/**
 * 拖拽結束事件處理器
 */
const handleMouseUp = () => {
  if (dragState.isDragging.value) {
    console.log('拖拽操作結束');
    dragState.isDragging.value = false;

    // 清理全域事件監聽器
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
  }
};

/**
 * 滑鼠離開容器事件處理器
 *
 * 當滑鼠移出甘特圖容器時,自動結束拖拽操作
 */
const handleMouseLeave = () => {
  if (dragState.isDragging.value) {
    console.log('滑鼠移出容器,結束拖拽操作');
    dragState.isDragging.value = false;

    // 清理事件監聽器
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
  }
};

這個拖拽系統的設計考慮了多個關鍵點:

事件捕獲策略 我們使用容器級別的事件捕獲,而不是在每個單元格上綁定事件。這不僅提高了效能,也簡化了事件管理邏輯。

座標計算的精確性 使用 getBoundingClientRect() 獲取容器的精確位置,確保在頁面有捲動或容器有偏移時,座標計算仍然準確。

全域事件處理 在拖拽過程中註冊全域的 mousemovemouseup 事件,確保即使滑鼠移出容器範圍,拖拽操作仍能正常完成。

程式化捲動的智慧實現

除了使用者主動的拖拽操作,甘特圖還需要支援程式控制的捲動,比如快速導航到特定任務或今日位置。

/**
 * 捲動到特定任務的監聽器
 *
 * 使用 Vue 3 的 watch API 監聽全域狀態變化
 * 當需要捲動到特定任務時,自動執行捲動操作
 */
watch(taskToScrollTo, (newTaskId, oldTaskId) => {
  // 輸入驗證
  if (!newTaskId || !containerRef.value) {
    console.debug('任務 ID 無效或容器未就緒', { newTaskId, oldTaskId });
    return;
  }

  console.log(`準備捲動到任務: ${newTaskId}`);

  try {
    // 使用 CSS 屬性選擇器精確查找目標任務列
    const taskRowSelector = `tr[data-task-id="${newTaskId}"]`;
    const taskRow = containerRef.value.querySelector(taskRowSelector);

    if (taskRow) {
      console.log('找到目標任務列,開始執行捲動動畫');

      // 使用瀏覽器原生 API 實現平滑捲動
      taskRow.scrollIntoView({
        behavior: 'smooth',      // 平滑動畫效果
        block: 'center',         // 垂直置中對齊
        inline: 'nearest'        // 水平位置保持最近
      });

      // 可選:添加視覺高亮效果
      taskRow.classList.add('scroll-highlight');
      setTimeout(() => {
        taskRow.classList.remove('scroll-highlight');
      }, 2000);

      console.log('捲動操作完成');
    } else {
      console.warn(`找不到任務 ID 為 ${newTaskId} 的 DOM 元素`);
    }
  } catch (error) {
    console.error('捲動到任務時發生錯誤', { newTaskId, error });
  } finally {
    // 重置狀態,避免重複觸發
    nextTick(() => {
      taskToScrollTo.value = null;
    });
  }
});

/**
 * 捲動到今日位置的複雜實現
 *
 * 這個功能需要計算今日在時間軸上的精確位置
 * 並執行水平捲動將其置於視窗中央
 */
watch(scrollToTodayTrigger, (newValue, oldValue) => {
  // 觸發器值變化檢查
  if (newValue === oldValue || !containerRef.value) {
    return;
  }

  console.log('開始執行捲動到今日功能');

  try {
    // 獲取標準化的今日日期
    const today = new Date();
    today.setHours(0, 0, 0, 0); // 標準化到當日午夜

    let targetWeekStart = null;

    // 在時間軸中搜尋包含今日的週區間
    for (let i = 0; i < timelineDates.value.length; i++) {
      const weekStart = timelineDates.value[i];
      const weekEnd = new Date(weekStart);
      weekEnd.setDate(weekEnd.getDate() + 6);
      weekEnd.setHours(23, 59, 59, 999);

      if (today >= weekStart && today <= weekEnd) {
        targetWeekStart = weekStart;
        break;
      }
    }

    // 處理今日不在專案時間範圍內的情況
    if (!targetWeekStart) {
      if (timelineDates.value.length === 0) {
        console.warn('時間軸為空,無法執行捲動');
        return;
      }

      const projectStart = timelineDates.value[0];
      const projectEnd = timelineDates.value[timelineDates.value.length - 1];

      if (today < projectStart) {
        containerRef.value.scrollTo({ left: 0, behavior: 'smooth' });
      } else if (today > projectEnd) {
        const maxScrollLeft = containerRef.value.scrollWidth - containerRef.value.clientWidth;
        containerRef.value.scrollTo({ left: maxScrollLeft, behavior: 'smooth' });
      }
      return;
    }

    // 尋找對應的時間軸表頭元素
    const headerRow = containerRef.value.querySelector('thead tr');
    if (!headerRow) {
      console.error('找不到表頭列,無法執行水平捲動');
      return;
    }

    const targetDateString = formatDateForDisplay(targetWeekStart);
    const timeAxisHeaders = Array.from(headerRow.querySelectorAll('.vertical-header'));
    const targetHeader = timeAxisHeaders.find(th => th.textContent.trim() === targetDateString);

    if (targetHeader) {
      const containerWidth = containerRef.value.clientWidth;
      const headerLeft = targetHeader.offsetLeft;
      const headerWidth = targetHeader.offsetWidth;
      const centerScrollLeft = headerLeft - (containerWidth / 2) + (headerWidth / 2);
      const maxScrollLeft = containerRef.value.scrollWidth - containerWidth;
      const finalScrollLeft = Math.max(0, Math.min(maxScrollLeft, centerScrollLeft));

      containerRef.value.scrollTo({
        left: finalScrollLeft,
        behavior: 'smooth'
      });
    } else {
      console.warn(`找不到日期為 ${targetDateString} 的表頭元素`);
    }
  } catch (error) {
    console.error('執行捲動到今日時發生錯誤', error);
  }
});

這個程式化捲動系統的特點:

智慧邊界處理 當今日不在專案時間範圍內時,系統會智慧地捲動到最適合的位置:早於開始日期時捲動到最左側,晚於結束日期時捲動到最右側。

精確的位置計算 置中捲動的計算考慮了容器寬度、目標元素位置和寬度,確保目標週能夠精確地出現在視窗中央。

錯誤處理和日誌 完善的錯誤處理和詳細的日誌記錄,便於除錯和維護。

垂直捲動的智慧控制

甘特圖的垂直捲動控制是一個平衡使用者體驗和效能的設計。

/**
 * 垂直捲動的智慧控制系統
 *
 * 根據專案規模動態決定是否啟用垂直捲動
 */
const TASK_THRESHOLD = 15;

const isVerticalScrollEnabled = computed(() => {
  const taskCount = props.tasks.length;
  return taskCount > TASK_THRESHOLD;
});

/**
 * 動態 CSS 類別的計算
 *
 * 根據垂直捲動的啟用狀態,動態添加對應的 CSS 類別
 */
const dynamicClasses = computed(() => ({
  'is-grabbing': isDragging.value,
  'enable-vertical-scroll': isVerticalScrollEnabled.value,
  'no-vertical-scroll': !isVerticalScrollEnabled.value,
  'has-selected-tasks': props.selectedTaskIds && props.selectedTaskIds.size > 0,
  'drag-mode-active': isDragMode.value
}));

這種動態控制的好處是:

小專案的完整視圖 當任務數量較少時,禁用垂直捲動讓使用者能夠一眼看到整個專案的全貌,這對於小型專案的管理非常有幫助。

大專案的可管理性 當任務數量較多時,啟用垂直捲動避免了頁面無限拉長,保持了介面的整潔和可操作性。

平滑的過渡體驗 當專案規模從小變大(或從大變小)時,捲動模式的切換是自動和平滑的,使用者不會感到突兀。

固定欄位系統:精確的位置計算

固定欄位是甘特圖最具技術挑戰性的功能之一。它需要在水平捲動時保持特定欄位的可見性,同時確保所有元素的完美對齊。

固定欄位的核心實現邏輯

/**
 * 固定欄位位置計算系統
 *
 * 這個系統負責計算和設定所有固定欄位的精確位置
 */
const updateStickyColumns = () => {
  if (!tableRef.value) return;

  const table = tableRef.value;
  const headerRow = table.querySelector('thead tr');
  if (!headerRow) return;

  console.group('開始更新固定欄位位置');

  try {
    // === 左側固定欄位 ===
    let leftAccumulator = 0;
    const leftHeaders = headerRow.querySelectorAll('.sticky-left');
    leftHeaders.forEach(header => {
      header.style.left = `${leftAccumulator}px`;
      header.style.position = 'sticky';
      header.style.zIndex = '10';
      header.style.backgroundColor = '#fff';
      leftAccumulator += header.offsetWidth;
    });

    const bodyRows = table.querySelectorAll('tbody tr');
    bodyRows.forEach(row => {
      let cellLeftOffset = 0;
      const leftCells = row.querySelectorAll('.sticky-left');
      leftCells.forEach((cell, cellIndex) => {
        cell.style.left = `${cellLeftOffset}px`;
        cell.style.position = 'sticky';
        cell.style.zIndex = '9';
        cell.style.backgroundColor = '#fff';
        if (leftHeaders[cellIndex]) {
          cellLeftOffset += leftHeaders[cellIndex].offsetWidth;
        }
      });
    });

    // === 右側固定欄位 ===
    let rightAccumulator = 0;
    const rightHeaders = Array.from(headerRow.querySelectorAll('.sticky-right')).reverse();
    rightHeaders.forEach(header => {
      header.style.right = `${rightAccumulator}px`;
      header.style.position = 'sticky';
      header.style.zIndex = '10';
      header.style.backgroundColor = '#fff';
      rightAccumulator += header.offsetWidth;
    });

    bodyRows.forEach(row => {
      let cellRightOffset = 0;
      const rightCells = Array.from(row.querySelectorAll('.sticky-right')).reverse();
      rightCells.forEach((cell, cellIndex) => {
        cell.style.right = `${cellRightOffset}px`;
        cell.style.position = 'sticky';
        cell.style.zIndex = '9';
        cell.style.backgroundColor = '#fff';
        if (rightHeaders[cellIndex]) {
          cellRightOffset += rightHeaders[cellIndex].offsetWidth;
        }
      });
    });

  } catch (error) {
    console.error('更新固定欄位位置時發生錯誤', error);
  } finally {
    console.groupEnd();
  }
};

// 使用 Vue 生命週期鉤子確保在適當時機更新
onMounted(() => {
  nextTick(updateStickyColumns);
});

onUpdated(() => {
  nextTick(updateStickyColumns);
});

const debouncedUpdateStickyColumns = debounce(updateStickyColumns, 100);

const handleResize = () => {
  debouncedUpdateStickyColumns();
};

onMounted(() => {
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', handleResize);
});

這個固定欄位系統的技術特點:

精確的寬度計算 系統不依賴 CSS 中設定的固定寬度,而是動態讀取每個元素的實際渲染寬度。這確保了在不同字體、縮放比例、或內容長度下都能保持完美對齊。

層級管理 通過精心設計的 z-index 值確保固定欄位始終顯示在正確的層級上。表頭的 z-index 為 10,內容的 z-index 為 9,這樣表頭在捲動時會覆蓋內容。

背景處理 每個固定欄位都設定了不透明的白色背景,防止捲動內容在固定欄位下方顯示時造成視覺干擾。

效能優化 使用防抖技術避免在頻繁的視窗大小變化或內容更新時過度執行位置計算。

固定欄位的樣式配合

CSS 樣式與 JavaScript 邏輯需要密切配合才能實現完美的固定欄位效果。

.gantt-table-scroll-wrapper {
  overflow-x: auto;
}

#gantt-table {
  border-collapse: separate;
  border-spacing: 0;
}

.sticky-left, .sticky-right {
  position: sticky;
  background: #fff !important;
  z-index: 10;
}
  • overflow-x: auto:啟用水平捲動。

  • border-collapse: separate:這是 position: sticky<table> 中正常工作的關鍵設定。

  • position: sticky:核心屬性,JavaScript 會動態設定 leftright 值。

事件處理系統:多重互動的優雅管理

甘特圖需要處理多種複雜的使用者互動,包括任務選取、編輯觸發、拖拽捲動等。設計一個清晰的事件處理系統是確保良好使用者體驗的關鍵。

事件優先級和衝突處理

/**
 * 事件處理系統的設計原則
 *
 * 1. 事件優先級:拖拽 > 編輯 > 選取
 * 2. 事件隔離:不同功能的事件處理器相互獨立
 * 3. 狀態一致性:確保 UI 狀態與資料狀態同步
 */

const toggleSelection = (taskId) => {
  if (!taskId || typeof taskId !== 'number') return;
  emit('toggle-select-task', taskId);
};

const editingTask = ref(null);

const openTaskModal = (task) => {
  if (!task || !task.id) return;
  editingTask.value = task;
};

const closeTaskModal = () => {
  editingTask.value = null;
};

const handleKeyboardShortcuts = (e) => {
  const isInputActive = ['input', 'textarea', 'select'].includes(
    e.target.tagName.toLowerCase()
  );
  if (isInputActive) return;

  switch (e.key) {
    case 'Escape':
      if (editingTask.value) {
        e.preventDefault();
        closeTaskModal();
      }
      break;
    case 'Home':
      e.preventDefault();
      containerRef.value?.scrollTo({ left: 0, behavior: 'smooth' });
      break;
    case 'End':
      e.preventDefault();
      const maxScrollLeft = containerRef.value.scrollWidth - containerRef.value.clientWidth;
      containerRef.value?.scrollTo({ left: maxScrollLeft, behavior: 'smooth' });
      break;
  }
};

生命週期中的事件管理

onMounted(() => {
  document.addEventListener('keydown', handleKeyboardShortcuts);
  window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeyboardShortcuts);
  document.removeEventListener('mousemove', handleMouseMove);
  document.removeEventListener('mouseup', handleMouseUp);
  window.removeEventListener('resize', handleResize);
});

正確地在 onMounted 中註冊事件,並在 onUnmounted 中清理,是避免記憶體洩漏的關鍵。

樣式系統:現代化的視覺設計

CSS 樣式系統是甘特圖使用者體驗的重要組成部分。我們設計了一個既美觀又實用的視覺系統。

  • 主容器和佈局:使用 Flexbox 佈局,will-change: scroll-position 提示瀏覽器優化捲動效能。

  • 拖拽狀態回饋cursor: grabbinguser-select: none 提供了清晰的視覺和互動回饋。

  • 任務互動樣式:選中狀態使用漸變背景和左側邊框創造明顯的視覺區分,懸停狀態有輕微的位移效果增加趣味性。

  • 任務狀態點設計:使用不同顏色的漸變和陰影,並加上呼吸動畫增加視覺活力。

  • 自訂捲動條:設計了符合整體風格的捲動條,提升視覺一致性。

  • 響應式與暗色主題:使用媒體查詢(Media Queries)支援不同螢幕尺寸和暗色模式。

/* 選中任務的樣式 */
.task-name-cell.selected {
  background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%) !important;
  border-left: 3px solid #2196f3;
  font-weight: 600;
}

/* 任務狀態點的樣式 */
.dot {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 50%;
  animation: breathe 2s ease-in-out infinite;
}

.dot.dot-completed {
  background: linear-gradient(135deg, #4caf50 0%, #8bc34a 100%);
}
.dot.dot-critical {
  background: linear-gradient(135deg, #f44336 0%, #ff9800 100%);
}

@keyframes breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.7; }
}

/* 暗色主題支援 */
@media (prefers-color-scheme: dark) {
  .gantt-table-scroll-wrapper {
    background: #2d2d2d;
    border-color: #404040;
  }
  #gantt-table th, #gantt-table td, .sticky-left, .sticky-right {
    background: #2d2d2d !important;
    color: #e0e0e0;
    border-bottom-color: #404040;
  }
}

效能優化:處理大量資料的策略

當甘特圖需要處理包含數百個任務的大型專案時,效能優化就變得至關重要。

計算屬性快取策略

Vue 的 computed 屬性本身就帶有快取機制。對於更複雜的計算,我們可以手動實現快取。

const timelineDateCache = new WeakMap();

const timelineDates = computed(() => {
  if (timelineDateCache.has(props.timelineSettings)) {
    return timelineDateCache.get(props.timelineSettings);
  }
  const dates = generateTimelineDates(props.timelineSettings);
  timelineDateCache.set(props.timelineSettings, dates);
  return dates;
});

節流和防抖優化

對於高頻觸發的事件,如 mousemoveresize,使用節流(throttle)和防抖(debounce)是標準的優化手段。

// 滑鼠移動事件的節流處理 (約 60fps)
const throttledMouseMove = throttle(handleMouseMove, 16);

// 視窗大小變化的防抖處理
const debouncedResize = debounce(handleResize, 250);

DOM 查詢最佳化與批次更新

避免在迴圈中重複查詢 DOM,並將多次 DOM 操作合併為一次,以減少瀏覽器的重排(reflow)和重繪(repaint)。

class BatchDOMUpdater {
  constructor() {
    this.updates = [];
    this.scheduled = false;
  }
  addUpdate(element, property, value) {
    this.updates.push({ element, property, value });
    this.scheduleUpdate();
  }
  scheduleUpdate() {
    if (this.scheduled) return;
    this.scheduled = true;
    requestAnimationFrame(() => this.flushUpdates());
  }
  flushUpdates() {
    this.updates.forEach(({ element, property, value }) => {
      element.style[property] = value;
    });
    this.updates = [];
    this.scheduled = false;
  }
}

const domUpdater = new BatchDOMUpdater();
// 在 updateStickyColumns 中使用 domUpdater.addUpdate(...)

虛擬捲動的考量

對於超過數百甚至上千個任務的極端情況,可以引入虛擬捲動技術。只渲染可視區域內的任務 DOM,用空白的佔位元素模擬整個捲動區域的高度。

測試策略:品質保證的完整體系

全面的測試策略是確保甘特圖元件品質和穩定性的關鍵。

單元測試

使用 Vitest 和 Vue Testing Library,針對元件的各個獨立部分進行測試。

  • 基礎渲染測試:驗證元件是否能根據 props 正確渲染 DOM 結構。

  • 計算屬性測試:驗證 timelineDatesisVerticalScrollEnabled 等計算邏輯的正確性。

  • 互動功能測試:模擬使用者操作(如點擊、雙擊),驗證是否觸發了正確的事件或狀態變更。

  • 錯誤處理測試:測試在傳入無效 props 或邊界情況下,元件是否能優雅地處理錯誤。

端對端(E2E)測試

使用 Playwright 等工具,在真實瀏覽器環境中模擬完整的使用者流程。

  • 完整工作流程測試:從載入頁面、選取任務、打開編輯視窗、修改並儲存,驗證整個流程是否通順。

  • 拖拽捲動測試:模擬滑鼠拖拽,驗證容器的捲動位置是否如預期般改變。

  • 響應式設計測試:在不同視窗尺寸下執行測試,確保佈局和功能正常。

  • 跨瀏覽器相容性測試:在 Chromium, Firefox, WebKit 等主流瀏覽器核心上執行測試。

部署與維護:生產環境的考量

建置優化配置

vite.config.js 中進行建置優化,例如:

  • 程式碼分割:使用 manualChunks 將大型依賴(如 vue, lodash)分離出來,利用瀏覽器快取。

  • 程式碼壓縮:在生產環境中啟用 Terser 壓縮,並移除 console.log

  • 目標瀏覽器:設定 target 以生成相容性更好的 JavaScript 程式碼。

監控和分析

在生產環境中整合效能監控和錯誤追蹤工具。

  • 效能監控:記錄元件的渲染時間、捲動事件頻率、記憶體使用情況等指標,及時發現效能瓶頸。

  • 錯誤報告:捕獲元件運行時的錯誤,並將錯誤資訊(包括堆疊追蹤、使用者環境)發送到監控平台,以便快速定位和修復問題。

總結與未來發展

透過這次甘特圖元件的開發實戰,我們深入探討了現代前端開發中的多個關鍵技術領域。

技術成果總結

  • Vue 3 Composition API 的深度應用:充分利用其靈活性和可組合性,使程式碼邏輯更清晰。

  • 複雜 UI 互動的系統性解決方案:建立了一套處理事件優先級、避免衝突的完整事件系統。

  • 效能優化的全方位實踐:從快取、事件處理到 DOM 操作,應用了多種優化策略。

  • 測試驅動開發的完整實踐:建立了從單元到 E2E 的完整測試體系,確保元件品質。

未來發展方向

  • 智慧化功能增強:加入基於機器學習的任務時間預測、風險評估等功能。

  • 協作功能深化:實現即時協作編輯、評論系統、變更歷史追蹤等功能。

  • 整合生態擴展:與更多專案管理工具、日曆應用、通訊軟體整合。

  • 跨平台支援:應用 Progressive Web App(PWA)技術,提供原生應用般的體驗。

這個甘特圖元件的開發過程不僅是技術實現的過程,更是對現代前端開發最佳實踐的全面探索。希望這些經驗和思考能夠為你的前端開發工作提供有價值的參考和啟發。